Skip to content

fix(plugin-auth)!: an SSO provider's domain list takes bare host names only, and every rule reads each entry the same way - #33267

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/area-A1-32960
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/area-A1-32960

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #32960
Fixes #32975

Clause-②: yes (narrowing) — narrowed: POST /sso/register, POST /sso/update-provider and the bridges that re-dispatch to them answer 400 VALIDATION_FAILED on domain (fields[0].code: 'invalid_value', naming the entry) for a domain value holding an entry that is not a bare host name, where they stored it; SCIM adoption of an existing SSO account no longer reads an entry or an address that only folds into a host (full-width letters) as a domain. Widened: SCIM adoption reads an entry and an address in their one spelling, so a root dot and the Unicode / punycode forms of an internationalised name name the same domain, as the account rule already read them. New module exports ssoDomainEntryHost, ssoDomainEntryHosts, ssoDomainEntryNotBare (sso-account-at-proven-domain.ts).
Metadata-protocol: none
Skills-docs: none — no skill under skills/** teaches the SSO provider domain list; content/docs/permissions/sso.mdx gains the rule and the correction in this change.

维护者速读

  • 改了什么:SSO 提供方的「域名列表」只收裸主机名(如 acme.com)。注册或修改时写成网址、带端口或路径(https://acme.com、acme.com:443)会被 400 拒绝并点名那一条,什么都不存。读这个列表的规则(新建账号的已证明域名规则、SCIM 接管已有账号)现在用同一个解析,同一条目在各规则下指同一个域名。
  • 为什么改:fix(plugin-auth)!: with SSO domain verification on, a provider creates an account only for an email address at a domain it has proven #32669 之后,网址形状的条目会被存下、通过 DNS 验证,却在登录时什么都证明不了;SCIM 接管还用着另一套更松的解析。
  • 风险与回滚:只拒绝新写入的非裸条目;已存的不改写。回滚即还原本 PR。
  • 先看:sso-account-at-proven-domain.ts(ssoDomainEntryHost / ssoDomainEntryNotBare),scim-sso-adoption.ts(改用共享解析),auth-manager.ts assertSsoProviderDomainFree(拒绝的接线)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/sso-account-at-proven-domain.test.ts src/sso-provider-lifecycle.test.ts。
  • 需要你做的:无,评审通过后区域线程落地。

What changes

  • One parse (sso-account-at-proven-domain.ts): ssoDomainEntryHost(entry) (the account rule's entry reading, now exported and named for what it is) and ssoDomainEntryHosts(domain) (the list). The account rule reads through them as before; scim-sso-adoption.ts drops its own split-trim-lowercase parse and reads the provider list through ssoDomainEntryHosts and the address through ssoAddressDomain. So an entry the parse does not accept (a URL, a port, a path) names no domain for SCIM either (fix(plugin-auth): every rule that reads an SSO provider's domain list reads each entry the same way #32975).
  • Register and edit refuse a non-bare entry (feat(plugin-auth): an SSO provider's domain list takes bare hostnames only, refused at register and edit #32960): ssoDomainEntryNotBare(domain) names the first entry the shared parse does not accept; assertSsoProviderDomainFree (the before-hook on /sso/register and a domain edit on /sso/update-provider, which the register bridges reach) throws it as 400 VALIDATION_FAILED on domain, after the length refusal and before the claim rule. Blank entries are skipped, as every parse of the list skips them. Nothing is saved.
  • The claim rule (sso-provider-lifecycle.ts providerDomains, one provider per domain) is unchanged. With the refusal in front, every entry a request can now store reads the same under it as under the shared parse (pinned for every spelling in the table). A stored entry of ANOTHER provider saved before this rule (say https://acme.com) still blocks a collision on acme.com: the claim rule mirrors the vendor's address routing, which still reads it, so reading it more leniently there only ever refuses more.
  • Existing rows are not rewritten; an edit that writes no domain is not judged. content/docs/permissions/sso.mdx says how to correct a stored entry (rewrite it as the bare host, then verify again).

Why

#32960: with domain verification on, #32669 counts an entry as proven only when it is a bare host; a URL- or port-shaped entry was stored, passed the vendor's DNS check, and then admitted no one. #32975: three rules read the same list three ways; one stored entry could name a domain under one and not another.

Verification

At head a10e98535f (branch point origin/main e540baf8; no file this PR touches changed on main since):

  • sso-account-at-proven-domain.test.ts (+7): the account rule and the SCIM rule judged over 14 entry spellings × 5 addresses agree on every pair; non-bare entries name nothing for SCIM; SCIM reads a root dot and punycode as the same domain; CONTROL bare host under all three rules; every entry register accepts reads the same under the claim rule and the shared parse; the refusal names the entry with VALIDATION_FAILED / invalid_value; CONTROL bare names, blanks, empty.
  • sso-provider-lifecycle.test.ts (+4): register (direct door and the SAML bridge) and a domain edit refuse five non-bare shapes with 400 naming the entry, nothing written, the stored domain kept; CONTROL bare names stored at register and edit; a provider stored with a URL entry before the rule is untouched by an edit without a domain and corrected by the documented edit. Two claim cases that used https://… request values now use …. (a root dot) for the same "another spelling" leg, since the shape refusal answers a URL first.
  • Targeted: sso-account-at-proven-domain, sso-provider-lifecycle, scim-sso-adoption: 87 passed.
  • Ablations (fix committed, mutation, restore to HEAD, git diff HEAD empty): SCIM back to its own split-lowercase parse → 2 failed (the agreement table, the one-spelling case); the register refusal unwired in assertSsoProviderDomainFree → 2 failed (both door refusals).
  • @objectstack/plugin-auth full suite: Test Files 269 passed (269), Tests 5125 passed | 10 skipped; typecheck exit 0 (with check:test-typecheck).
  • Gates: the derived families (dispatch-gates --commands) are read in the review request; CI runs the full set.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (Not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (CI).
  • 4. One issue, one branch off an up-to-date main, one pull request (two coupled cards on one function set, one PR, per the area plan).
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The 维护者速读 section is the frame's Chinese summary.)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo

…, read by one parse in every rule (#32960, #32975)

Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 14 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 199e3624426faa50f413e18657abc44ea20ff3cb.

⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: VALIDATION_FAILED (literal, 46 pages)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 199e3624426faa50f413e18657abc44ea20ff3cb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a426d6f07172e436350b9ef71eb5865d34d9069a — the merge of head 88e7099e59ac0bcee82457a1bf091251cd4ece82 into base 199e3624426faa50f413e18657abc44ea20ff3cb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a426d6f07172e436350b9ef71eb5865d34d9069a && git checkout a426d6f07172e436350b9ef71eb5865d34d9069a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 199e3624426faa50f413e18657abc44ea20ff3cb 88e7099e59ac0bcee82457a1bf091251cd4ece82 && git checkout -B drift-repro 199e3624426faa50f413e18657abc44ea20ff3cb && git merge --no-ff 88e7099e59ac0bcee82457a1bf091251cd4ece82

node scripts/docs-audit/affected-docs.mjs --json 199e3624426faa50f413e18657abc44ea20ff3cb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 199e3624426faa50f413e18657abc44ea20ff3cb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo

Request: review · area A1 · Fixes #32960 #32975 · head a10e985 · requested at 2026-11-14T15:19Z

Local at this head: plugin-auth full suite 269 files / 5125 passed; typecheck exit 0; ablations in the body; derived gates running; CI running.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head a10e985 · 2026-11-14T15:21:12Z

review#3 (area-lease pilot #33059, area A1).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a10e98535fc97cf953c32fc96392640c107a5318
Local-runs: none
Scope: packages/plugins/plugin-auth/src/sso-account-at-proven-domain.ts, packages/plugins/plugin-auth/src/scim-sso-adoption.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/sso-account-at-proven-domain.test.ts, packages/plugins/plugin-auth/src/sso-provider-lifecycle.test.ts + packages/plugins/plugin-auth/src/sso-provider-lifecycle.ts, packages/plugins/plugin-auth/src/auth-manager-before-hook.ts, packages/plugins/plugin-auth/src/audience-posture.ts, packages/platform-objects/src/identity/sys-sso-provider.object.ts (read only)

① Derived judgments

  • One parse (fix(plugin-auth): every rule that reads an SSO provider's domain list reads each entry the same way #32975). ssoDomainEntryHost is the former provenEntryHost, exported unchanged in body; ssoProviderProvenDomains now delegates to ssoDomainEntryHosts with identical semantics (skip non-accepted entries, dedupe). scim-sso-adoption.ts drops its own split/lowercase parse and its emailDomain and reads both sides through the shared parse (ssoAddressDomain, ssoDomainEntryHosts), so SCIM and the account rule agree on every entry/address pair (14×5 agreement table pinned). Directions: narrowed (an entry or address that only folds into a host — full-width letters, URL/port/path shapes — no longer vouches for SCIM adoption) and widened (root dot, Unicode/punycode read as one domain, as the account rule already did). Both are declared. The null vs undefined switch at the vouching line is consistent with ssoAddressDomain's return.
  • Register/edit refusal (feat(plugin-auth): an SSO provider's domain list takes bare hostnames only, refused at register and edit #32960). ssoDomainEntryNotBare runs in assertSsoProviderDomainFree after ssoDomainTooLong and before the claim rule, only on /sso/register and /sso/update-provider with a non-blank domain from a caller with a session; blanks skipped like every list parse. The bridges (/admin/sso/register, register-saml, /admin/sso/update-provider) re-dispatch through those paths, pinned for the direct door and the SAML bridge. The generic data layer for sys_sso_provider is read-only (enable.apiMethods), so no other write path stores domain. The console's edit actions (edit_oidc_provider, edit_saml_provider, toggles) send no domain, so an existing provider holding a URL entry stays editable for its other fields; nothing pre-existing is newly blocked from the console.
  • Claim rule (providerDomains, WHATWG host parse) untouched; with the shape refusal in front, every newly storable entry reads the same under both (pinned). A legacy URL entry of another provider still blocks a collision: refuses more, never less. Red line held.
  • nit N1: the docs and changeset say "edit the provider's domain to the bare host names", but no console action edits domain; the only door is POST /api/v1/auth/sso/update-provider (or the admin bridge) with domain. Name the door in both so an operator can act.
  • nit N2 (follow-up, not here): a provider saved before this rule with a URL entry now silently "names no domain" for SCIM adoption too. A one-time boot or Setup warning naming such providers would make the narrowing visible; a p3 loose-ends card at most.

② Semver level

@objectstack/plugin-auth: patch with fix(plugin-auth)! and Clause-②: yes (narrowing) (with its widened arm stated) matches the repo's convention for a runtime refusal at an auth door; no schema key, column or published type moves. New exports are module-level in sso-account-at-proven-domain.ts (not a published subpath change). adr-0087: not-required (no-migration-prescription) is right: stored rows are not rewritten. Metadata-protocol: none holds. Skills-docs: none — REASON acceptable.

③ Boundary flags

  • Security boundary: SSO account creation and SCIM adoption; both moves fail closed except the declared one-spelling widening, which only matches what the account rule already accepted.
  • Author login objectstack-fleet[bot] (relay); implemented by area A1 session_016WV4d1NJYFGVi2FRztyBNo.
  • CI at this head: 29 green, 8 in progress, none red when read (15:22Z). The ACCEPT must re-read checks.

④ Design judgment

Refusing the shape at the write door is the right place: an entry that passes the vendor's DNS check and then proves nothing at sign-in is a silent misconfiguration, and the 400 names the entry and the accepted form. Sharing one parse across the account rule and SCIM removes the three-readings divergence the card describes. Not rewriting stored rows is the conservative choice and is stated. Holds against the cited code and both cards' scenarios.

Implemented-by: claude/area-A1-32960
Reviewed-by: session_016WV4d1PGptBW3QRQNmy5e6

VERDICT: PASS

Nits only (N1 text, N2 optional follow-up); no delta owed.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo

ACCEPT · area A1 · Fixes #32960 #32975 · head 88e7099 · 2026-11-14T15:51Z

  • Reviewer: review#3 (session_016WV4d1PGptBW3QRQNmy5e6), CONTRACT_REVIEW_TIER PASS at a10e985 (record 6879639370).
  • Regen-provenance: prose-only move a10e985 → 88e7099 (N1: content/docs/permissions/sso.mdx + changeset name POST /api/v1/auth/sso/update-provider); review#3 confirmed the PASS carries (record 6879639370).
  • Governed: check-governed-merges.mjs --pr 33267 → NOT governed, 334 changed lines (≤ 3000).
  • Checks at 88e7099: 53 check runs completed, all success/skipped/neutral, none red. Mergeable: clean; main has not changed a file this PR touches.
  • Local: plugin-auth full suite green, typecheck exit 0; derived gates 105: 102 exit 0, 3 NOT MEASURED locally (exit 3, environmental: teardown-shape self-test needs an unshallow clone; skill-examples and dual-build-cjs-loads need a full build), CI ran them green.
  • Follow-up: review N2 (warn about stored URL-shaped domain entries) goes in one p3 loose-ends card.
  • Landing: ready + auto-merge (maintainer 2026-11-14 15:21Z: area PRs that pass review are readied and auto-merged by the area).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 15:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 3c42e63 Nov 14, 2026
54 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/area-A1-32960 branch November 14, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants